package org.xx.sipahi.controllers;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.vfs2.FileObject;
import org.apache.commons.vfs2.FileSystemException;
import org.apache.commons.vfs2.FileSystemManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import org.xx.armory.commons.ForLogging;
import org.xx.sipahi.FileContentRef;
import org.xx.sipahi.FileManagerUtils;
import org.xx.sipahi.FileRef;

import javax.servlet.http.HttpServletResponse;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;

import static java.util.Arrays.stream;
import static java.util.regex.Pattern.CASE_INSENSITIVE;
import static java.util.regex.Pattern.DOTALL;
import static java.util.regex.Pattern.UNICODE_CASE;
import static org.apache.commons.io.FilenameUtils.getExtension;
import static org.apache.commons.io.FilenameUtils.getName;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.trimToEmpty;
import static org.xx.armory.commons.RequestUtils.dumpString;
import static org.xx.armory.commons.RequestUtils.joinURLPath;
import static org.xx.armory.commons.ResponseUtils.contentType;
import static org.xx.armory.commons.ResponseUtils.encodeRFC2231;
import static org.xx.armory.commons.Validators.rejectIf;
import static org.xx.armory.spring5.mvc.ModelUtils.JSON_VIEW;
import static org.xx.sipahi.FileManagerUtils.ROOT;
import static org.xx.sipahi.FileManagerUtils.checkPath;
import static org.xx.sipahi.FileManagerUtils.isRootPath;
import static org.xx.sipahi.FileManagerUtils.relativize;
import static org.xx.sipahi.FileManagerUtils.resolve;

/**
 * 实现文件管理器的控制类。
 */
@Controller
@RequestMapping("/file-browser")
public class FileBrowserController {
    /**
     * 不允许在文件名中出现的字符。
     */
    private static final Pattern ILLEGAL_FILE_NAME_CHARS = Pattern.compile("[^a-zA-Z0-9~@!()\\[\\]._=+\\s-]", UNICODE_CASE | CASE_INSENSITIVE | DOTALL);

    @ForLogging
    private final
    Logger logger = LoggerFactory.getLogger(FileBrowserController.class);

    @Autowired
    private FileSystemManager fileSystemManager;

    @Value("${vfs.root-path}")
    private String rootPath;

    @GetMapping("/index")
    public ModelAndView index(
            @RequestParam(defaultValue = "") String dir
    )
            throws IOException {
        dir = joinURLPath(ROOT, dir.replace('\\', '/'));

        final var result = new ModelAndView();
        result.addObject("dir", dir);
        result.addObject("files", list(dir));
        return result;
    }

    @GetMapping("/browser")
    public ModelAndView browser(
            @RequestParam(defaultValue = "") String dir,
            @RequestParam("CKEditor") String editor,
            @RequestParam(value = "CKEditorFuncNum",
                          defaultValue = "-1") int funcNum
    )
            throws IOException {
        dir = joinURLPath(ROOT, dir.replace('\\', '/'));

        final var result = new ModelAndView();
        result.addObject("dir", dir);
        result.addObject("editor", editor);
        result.addObject("funcNum", funcNum);
        result.addObject("files", list(dir));
        return result;
    }

    private String removeTimestamp(
            String p
    ) {
        final var qp = p.trim().lastIndexOf('?');
        return qp < 0 ? p : p.substring(0, qp);
    }

    /**
     * 预览文件内容。
     * <p>此方法可能输出的不是文件的完整内容，只是文本文件的部分内容或者图片的缩略图。</p>
     *
     * @param response
     *         HTTP响应。
     * @param ifModifiedSince
     *         上次访问的时间戳。
     * @param path
     *         文件路径。
     * @return 模型和视图。
     * @throws IOException
     *         向HTTP响应中输出文件预览内容时出现错误。
     * @see #download(HttpServletResponse, String)
     */
    @GetMapping("/preview")
    public ModelAndView preview(
            HttpServletResponse response,
            @RequestHeader(value = "If-Modified-Since",
                           required = false) Date ifModifiedSince,
            @RequestParam String path
    )
            throws IOException {
        final String filePath = removeTimestamp(path);

        rejectIf(() -> isBlank(filePath), "文件路径不能为空。");

        final var now = new Date();

        final var rootFileObject = getRootFileObject();

        final var fileObject = resolve(rootFileObject, checkPath(path));
        if (!fileObject.isFile() || !fileObject.isReadable()) {
            // 找不到查看的文件，应当返回 NOT_FOUND。
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return null;
        }

        try (final var fileContentRef = new FileContentRef(rootFileObject.getName(), fileObject)) {
            final var ext = "." + getExtension(filePath);
            var fileContentType = contentType(ext);
            if (fileContentType.isEmpty()) {
                // 无法识别的内容类型，应当返回 FORBIDDEN。
                response.sendError(HttpServletResponse.SC_FORBIDDEN, "Extension \"" + ext + "\" Forbidden");
                return null;
            }

            response.addDateHeader("Date", now.getTime());

            if (ifModifiedSince != null && fileContentRef.getLastModified() <= ifModifiedSince.getTime()) {
                // 修改日期早于或者等于失效日期，应当返回 NOT_MODIFIED。
                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                return null;
            }

            // TODO: 输出缩略图或者摘要文本。
            if (fileContentType.startsWith("text/")) {
                // 文本类型。
                response.addHeader("Content-Type", fileContentType + ";charset=UTF-8");
                response.addHeader("Content-Length", String.valueOf(fileContentRef.getSize()));
                response.addHeader("Cache-Control", "public,max-age=60");
                response.addDateHeader("Last-Modified", fileContentRef.getLastModified());
                fileContentRef.write(response.getOutputStream());
            } else if (fileContentType.startsWith("image/")) {
                // 图片类型。
                response.addHeader("Content-Type", fileContentType);
                response.addHeader("Content-Length", String.valueOf(fileContentRef.getSize()));
                response.addHeader("Cache-Control", "public,max-age=60");
                response.addDateHeader("Last-Modified", fileContentRef.getLastModified());
                fileContentRef.write(response.getOutputStream());
            } else {
                // 其它类型，无法预览。
                response.addHeader("Content-Type", "application/octet-stream");
                response.addHeader("Content-Length", "0");
                response.addHeader("Cache-Control", "public,max-age=60");
                response.addDateHeader("Last-Modified", fileContentRef.getLastModified());
            }

            return null;
        }
    }

    /**
     * 修改文件名。
     *
     * @param path
     *         文件路径。
     * @param newFileName
     *         新文件名。
     * @return 模型和视图。
     * @throws IOException
     *         如果修改文件名时出现错误。
     */
    @PostMapping("/rename")
    public ModelAndView rename(
            @RequestParam String path,
            @RequestParam String newFileName
    )
            throws IOException {
        final var filePath = removeTimestamp(path);

        rejectIf(() -> isBlank(filePath), "文件路径不能为空。");
        rejectIf(() -> isBlank(newFileName), "新文件名不能为空");

        final var fileObject = resolve(getRootFileObject(), checkPath(path));
        if (!fileObject.isFile()) {
            throw new FileNotFoundException("Cannot find file: " + path);
        }

        final var newFileObject = findNotExists(changeBaseName(fileObject, newFileName));
        if (newFileObject == null) {
            throw new FileNotFoundException("Cannot rename to exists file: " + newFileName);
        }

        if (fileObject.canRenameTo(newFileObject)) {
            final var result = new ModelAndView(JSON_VIEW);
            result.addObject("oldFile", toFileRef(fileObject));

            fileObject.moveTo(newFileObject);

            result.addObject("newFile", toFileRef(newFileObject));
            return result;
        } else {
            throw new IOException("Cannot rename to: " + newFileName);
        }
    }

    /**
     * 删除文件。
     *
     * @param path
     *         文件路径。
     * @return 模型和视图。
     * @throws IOException
     *         如果删除文件时出现错误。
     */
    @PostMapping("/unlink")
    public ModelAndView unlink(
            @RequestParam String path
    )
            throws IOException {
        final var filePath = removeTimestamp(path);

        rejectIf(() -> isBlank(filePath), "文件路径不能为空。");

        final var fileObject = resolve(getRootFileObject(), checkPath(path));
        if (!fileObject.isFile()) {
            throw new FileNotFoundException("Cannot find file: " + path);
        }

        final var result = new ModelAndView(JSON_VIEW);
        result.addObject("file", toFileRef(fileObject));
        if (fileObject.delete()) {
            return result;
        } else {
            throw new IOException("Cannot unlink: " + path);
        }
    }

    /**
     * 下载文件。
     *
     * @param response
     *         HTTP响应。
     * @param path
     *         文件路径。
     * @throws IOException
     *         如果向HTTP响应中输出文件内容时出现错误。
     * @see #preview(HttpServletResponse, java.util.Date, String)
     */
    @GetMapping("/download")
    public void download(
            HttpServletResponse response,
            @RequestParam String path
    )
            throws IOException {
        final var filePath = removeTimestamp(path);

        rejectIf(() -> isBlank(filePath), "文件路径不能为空。");

        final var now = new Date();

        final var rootFileObject = getRootFileObject();

        final var fileObject = resolve(rootFileObject, checkPath(path));
        if (!fileObject.isFile() || !fileObject.isReadable()) {
            // 找不到查看的文件，应当返回 NOT_FOUND。
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        try (final var fileContentRef = new FileContentRef(rootFileObject.getName(), fileObject)) {
            final var ext = "." + getExtension(filePath);
            var fileContentType = contentType(ext);
            if (fileContentType.isEmpty()) {
                // 无法识别的内容类型，应当返回 FORBIDDEN。
                response.sendError(HttpServletResponse.SC_FORBIDDEN, "Extension \"" + ext + "\" Forbidden");
                return;
            }

            response.addDateHeader("Date", now.getTime());
            if (fileContentType.startsWith("text/")) {
                // 文本类型。
                response.addHeader("Content-Type", fileContentType + ";charset=UTF-8");
            } else {
                response.addHeader("Content-Type", fileContentType);
            }
            response.addHeader("Content-Length", String.valueOf(fileContentRef.getSize()));
            response.addHeader("Content-Disposition", "attachment;filename*=" + encodeRFC2231(getName(path)));
            fileContentRef.write(response.getOutputStream());
        }
    }

    private ModelAndView createUploadResult(
            FileContentRef fileContentRef,
            String errorMessage
    ) {
        final var result = new ModelAndView(JSON_VIEW);

        if (fileContentRef != null) {
            final var path = fileContentRef.getPath();
            result.addObject("uploaded", 1);
            result.addObject("fileName", getName(path));
            result.addObject("url", path);
        }

        if (!isBlank(errorMessage)) {
            final var error = new HashMap<String, String>();
            error.put("message", errorMessage);
            result.addObject("error", error);
        }

        return result;
    }

    /**
     * 上传文件。
     *
     * @param dir
     *         上传的目标文件夹。
     * @param editor
     *         发起上传的CKEditor编辑器名字。
     * @param funcNum
     *         CKEditor回调函数序号。
     * @param token
     *         CSRF令牌。
     * @param tokenToCompare
     *         用于验证的CSRF令牌。
     * @param file
     *         上传的文件。
     * @return 模型和视图。
     * @throws IOException
     *         如果读取上传文件或者保存文件时出现错误。
     */
    @PostMapping("/uploader")
    public ModelAndView upload(
            @RequestParam(defaultValue = "") String dir,
            @RequestParam(value = "CKEditor",
                          defaultValue = "") String editor,
            @RequestParam(value = "CKEditorFuncNum",
                          defaultValue = "-1") int funcNum,
            @RequestParam(value = "ckCsrfToken",
                          defaultValue = "") String token,
            @CookieValue(value = "ckCsrfToken",
                         defaultValue = "") String tokenToCompare,
            @RequestParam(value = "upload") MultipartFile file
    )
            throws IOException {
        final var originFileName = trimToEmpty(file.getOriginalFilename());
        rejectIf(originFileName::isEmpty, "上传文件的文件名不能为空");

        final var now = new Date();
        dir = joinURLPath(ROOT, dir.replace('\\', '/'));
        // 有的浏览器会在Multipart数据中包含文件名的完整路径，有些浏览器不会。
        // 从上传文件名中获取基本文件名（不包含路径部分）
        // 并将特殊字符替换为下划线(_)。
        final var fileName = ILLEGAL_FILE_NAME_CHARS.matcher(getName(originFileName)).replaceAll("_");
        final var date = new SimpleDateFormat("yyyyMMdd").format(now);
        final var fullPath = joinURLPath(dir, date, fileName);

        logger.debug("Upload \"{}\" to \"{}\"", dumpString(originFileName), dumpString(fullPath));

        if (!StringUtils.equalsIgnoreCase(token, tokenToCompare)) {
            throw new IllegalArgumentException("ckCsrfToken mismatched");
        }

        try {
            final var rootFileObject = getRootFileObject();
            final var oriFile = resolve(rootFileObject, checkPath(fullPath));
            final var createdFile = findNotExists(oriFile);
            if (createdFile == null) {
                return createUploadResult(null, "无法创建文件：" + fullPath);
            }

            createdFile.createFile();

            try (final var fileContentRef = new FileContentRef(rootFileObject.getName(), createdFile)) {
                fileContentRef.read(file.getInputStream());

                return createUploadResult(fileContentRef,
                                          createdFile == oriFile ? "" : "上传的文件被改名为：" + createdFile.getName().getBaseName());
            }
        } catch (IOException ex) {
            return createUploadResult(null, ex.getLocalizedMessage());
        }
    }

    /**
     * 上传文件。
     *
     * @param dir
     *         上传的目标文件夹。
     * @param file
     *         上传的文件。
     * @return 模型和视图。
     * @throws IOException
     *         如果读取上传文件或者保存文件时出现错误。
     */
    @PostMapping("/uploader2")
    public ModelAndView upload2(
            @RequestParam(defaultValue = "") String dir,
            @RequestParam(value = "upload") MultipartFile file
    )
            throws IOException {
        final var originFileName = trimToEmpty(file.getOriginalFilename());
        rejectIf(originFileName::isEmpty, "上传文件的文件名不能为空");

        dir = joinURLPath(ROOT, dir.replace('\\', '/'));
        // 有的浏览器会在Multipart数据中包含文件名的完整路径，有些浏览器不会。
        // 从上传文件名中获取基本文件名（不包含路径部分）
        // 并将特殊字符替换为下划线(_)。
        final var fileName = ILLEGAL_FILE_NAME_CHARS.matcher(getName(originFileName)).replaceAll("_");
        final var fullPath = joinURLPath(dir, fileName);

        logger.debug("Upload \"{}\" to \"{}\"", dumpString(originFileName), dumpString(dir));

        final var rootFileObject = getRootFileObject();
        final var createdFile = findNotExists(resolve(getRootFileObject(), checkPath(fullPath)));
        if (createdFile == null) {
            throw new IOException("Cannot create exists file: " + fullPath);
        }

        createdFile.createFile();

        try (final var fileContentRef = new FileContentRef(rootFileObject.getName(), createdFile)) {
            fileContentRef.read(file.getInputStream());

            final var result = new ModelAndView();
            result.addObject("file", fileContentRef);
            return result;
        }
    }

    private FileObject getRootFileObject()
            throws FileSystemException {
        return this.fileSystemManager.resolveFile(this.rootPath);
    }

    /**
     * 将vfs2的文件对象转为文件引用。
     *
     * @param fileObject
     *         vfs2文件对象。
     * @return 对应的文件引用。
     * @throws IllegalArgumentException
     *         如果参数{@code fileObject}是{@code null}。
     */
    private FileRef toFileRef(
            FileObject fileObject
    ) {
        rejectIf(() -> fileObject == null, null, "fileObject");

        try {
            return new FileRef(
                    fileObject.getName().getBaseName(),
                    relativize(getRootFileObject(), fileObject),
                    fileObject.isFile() ? fileObject.getContent().getSize() : 0L,
                    fileObject.getContent().getLastModifiedTime(),
                    fileObject.isFolder()
            );
        } catch (IOException ex) {
            logger.warn("Cannot get file-ref from " + fileObject, ex);
            return null;
        }
    }

    private List<FileRef> list(
            String path
    )
            throws IOException {
        final var dirObject = resolve(getRootFileObject(), checkPath(path));

        if (!dirObject.exists()) {
            throw new FileNotFoundException("Cannot find: " + path);
        }

        if (!dirObject.isFolder()) {
            throw new IOException(path + " is not a directory");
        }

        final var files = dirObject.getChildren();

        final var result = new ArrayList<FileRef>(files != null ? files.length + 2 : 2);
        // 如果当前路径不是根路径，那么添加根路径对象和上级路径对象。
        if (!isRootPath(path)) {
            result.add(new FileRef(".", ROOT, 0L, 0L, true));
            final var parent = dirObject.getParent();
            if (parent != null) {
                result.add(new FileRef("..", relativize(getRootFileObject(), parent), 0L, 0L, true));
            }
        }

        if (files != null && files.length > 0) {
            stream(files)
                    // 过滤不存在的文件，隐藏的文件和特殊文件。
                    .filter(FileManagerUtils::isNormal)
                    .map(this::toFileRef)
                    .filter(Objects::nonNull)
                    .forEach(result::add);
        }

        return result;
    }

    /**
     * 按照序号找到不存在的文件。
     *
     * @param fileObject
     *         目标文件对象。
     * @return 不存在的目标文件对象，如果增加序号到99以后仍然找不到不存在的目标文件则返回{@code null}，如果目标文件对象没有主文件名也返回{@code null}。
     * @throws java.io.IOException
     *         如果检测目标文件对象是否存在出错。
     * @throws IllegalArgumentException
     *         如果参数{@code fileObject}是{@code null}。
     */
    private FileObject findNotExists(
            FileObject fileObject
    )
            throws IOException {
        rejectIf(() -> fileObject == null, null, "fileObject");

        if (!fileObject.exists()) {
            return fileObject;
        }

        final var parent = fileObject.getParent();
        final var fileName = fileObject.getName().getBaseName();
        if (fileName.isEmpty()) {
            return null;
        }

        final var baseName = FilenameUtils.getBaseName(fileName);
        final var ext = FilenameUtils.getExtension(fileName);

        for (int i = 1; i < 100; ++i) {
            var newFileName = baseName + "(" + i + ")";
            if (!ext.isEmpty()) {
                newFileName = newFileName + "." + ext;
            }
            final var result = parent.resolveFile(newFileName);
            if (!result.exists()) {
                return result;
            }
        }

        return null;
    }

    /**
     * 修改文件对象的基础名字，保留所在目录和扩展名。
     *
     * @param fileObject
     *         待修改名字的文件对象。
     * @param newFileName
     *         新的文件名。
     * @return 已修改名字的新文件对象。
     * @throws java.io.IOException
     *         如果修改文件名出错。
     * @throws IllegalArgumentException
     *         如果参数{@code file}是{@code null}，如果参数{@code newFileName}是{@code null}或者只包含空白字符。
     */
    private FileObject changeBaseName(
            FileObject fileObject,
            String newFileName
    )
            throws IOException {
        rejectIf(() -> fileObject == null, null, "fileObject");
        rejectIf(() -> isBlank(newFileName), null, "newFileName");

        final var ext = fileObject.getName().getExtension();
        if (!ext.isEmpty()) {
            return fileObject.getParent().resolveFile(newFileName + "." + ext);
        } else {
            return fileObject.getParent().resolveFile(newFileName);
        }
    }
}
